Bridge : 앱과 웹, 쉽게 통신하기
React Native에서 WebView를 사용하면 앱 안에서 웹을 띄우는 건 아주 간단합니다.
하지만 앱과 웹이 서로 요청을 주고받는 구조, 즉 양방향 통신이 필요해지면
단순한 메시지 전달만으로는 부족한 순간이 찾아옵니다.
예를 들어:
- 웹에서 앱의 기능을 호출하거나
- 앱에서 웹의 상태를 업데이트하거나
- 파일 다운로드/업로드/공유 기능을 트리거하거나
이런 시나리오에서 서로 요청하고 응답하는 구조가 필요합니다.
기본적인 통신 방법
앱 → 웹: 메시지 보내고 받기
아래는 React Native에서 WebView를 통해 웹과 통신하는 코드입니다.
import React, { useEffect, useRef } from 'react';
import { WebView } from 'react-native-webview';
export default function AppComponent() {
const webviewRef = useRef<WebView>(null);
useEffect(() => {
const message = { type: 'ping' };
webviewRef.current?.postMessage(JSON.stringify(message)); // 웹에 메시지 전송
}, []);
const handleMessage = (event: any) => {
const message = JSON.parse(event.nativeEvent.data);
console.log('📨 From Web:', message);
};
return (
<WebView
ref={webviewRef}
source={{ uri: 'https://weolbu.com' }}
onMessage={handleMessage} // 웹에서 오는 메시지 수신
/>
);
}
아래는 웹에서 이벤트 리스너와 postMessage를 통해 앱과 통신하는 코드입니다.
// React 웹에서
useEffect(() => {
const message = { type: 'pong' };
window.ReactNativeWebView?.postMessage(JSON.stringify(message)); // 앱에 메시지 전송
}, []);
useEffect(() => {
window.addEventListener('message', (event) => {
console.log('📬 From App:', event.data); // 앱에서 보낸 메시지 수신
});
}, []);
앱에서는, WebView 객체의 postMessage 를 통해 웹에게 메세지를 전송하고 onMessage를 통해 웹으로부터 메세지를 수신할 수 있습니다. 웹에서는, ReactNative 객체의 postMessage를 통해 앱에게 메세지를 전송하고 이벤트 리스너를 통해 앱으로부터 메세지를 수신할 수 있습니다.
단순 메시지로는 부족한 이유
앞서 우리는 WebView의 postMessage와 onMessage를 통해 웹과 앱이 간단히 통신할 수 있다는 것을 봤습니다.
하지만 실제 서비스를 만들다 보면 단순한 "한 번 쏘고 끝" 메시지로는 충분하지 않습니다.
예를 들어 웹이 앱에 "현재 버전 알려줘"라고 요청하고, 앱은 그 응답을 돌려줘야 하는 케이스가 생깁니다.
그런 경우엔 아래와 같은 이유로 위에서 설명한 방식대로만으론 처리가 어렵습니다.
1. 요청과 응답을 연결할 수 없다
2. 실행 컨텍스트가 연결되어 있지 않다 - 중간 과정을 기억하기 어렵다
3. 여러 요청이 동시에 발생하면 꼬이기 쉽다
문제 1: 요청과 응답을 연결할 수 없다
앱에서 웹에 버전 정보를 요청했다고 가정해봅시다.
// 앱 → 웹
webviewRef.current?.postMessage(JSON.stringify({ type: 'getVersion' }));
// 웹
window.addEventListener('message', (event) => {
const { type } = JSON.parse(event.data);
if (type === 'getVersion') {
window.ReactNativeWebView.postMessage(JSON.stringify({ version: '1.2.3' }));
}
});
이렇게 하면 그저 "version"이라는 값이 날아왔을 뿐입니다. "누가 요청했는지" 와 "어떤 요청의 응답인지" 를 알 방법이 없습니다.
문제 2: 실행 컨텍스트가 사라진다
const fetchVersion = () => {
const handleSaveVersionWithSomeData = () => {
let 아래에서_생성되는_값;
// ... 복잡한 로직
webviewRef.current?.postMessage(JSON.stringify({ type: 'getVersion' }));
// 응답을 받고 처리하고 싶은데... version을 받을 수가 없네...
// saveWithSomeData(version, 아래에서_생성되는_값);
};
window.addEventListener('message', (event) => {
const { version } = JSON.parse(event.data);
});
};
위 코드처럼 이벤트 리스너나 postMessage 호출하는 컨텍스트가 다르기 때문에 한 함수의 흐름에서 로직을 처리하기가 어렵습니다 "내가 보낸 요청" 과 "그에 대한 응답" 을 연결할 방법이 없습니다.
문제 3: 동시에 여러 요청이 들어오면 꼬인다
// 두 요청을 동시에 보냄
webviewRef.current?.postMessage(JSON.stringify({ type: 'getUserInfo' }));
webviewRef.current?.postMessage(JSON.stringify({ type: 'getSettings' }));
// 웹에서는 이런 응답을 줄 수 있음
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getUserInfo', data: { name: 'Tyranno' } }));
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getSettings', data: { darkMode: true } }));
이런 구조에서는 앱 측에서 어떤 요청의 응답인지, 누구에게 전달해야 하는지 알 수 없습니다. 실수로 응답이 엇갈리면 전혀 다른 데이터가 잘못 처리됩니다.
그래서, 요청에 고유한 ID를 붙이고 응답에는 replyTo: id를 붙여서 요청 → 응답을 정확히 매칭하는 방식이 필요합니다. 그리고 요청을 보낸 시점의 컨텍스트를 기억하는 방식으로 await로 자연스럽게 사용할 수 있는 구조를 만들어야 합니다.
아래에서는 이 방식으로 구현한 Bridge 클래스를 소개합니다.
해결책: ID 기반으로 Promise의 Resolver 저장하기
우리는 이 문제를 다음과 같이 해결했습니다:
- 모든 요청에는 고유한
id가 필요 - 모든 요청은 Promise를 리턴하고
Map<id, resolver>형태로 저장한다 - 해당 요청에 대한 응답은
replyTo로id를 되돌려줘야 함 - replyTo에 맞는 resolver를 실행시킨다
이 구조를 활용하면 다음이 가능합니다:
Promise기반으로await로 메시지를 주고받을 수 있고- 동시에 여러 요청이 들어와도 서로 꼬이지 않으며
- 컨텍스트(콜백)를 유지한 채 명확한 응답을 받을 수 있음
이것을 구현하기 위해 Bridge라는 싱글톤 객체로 만들었습니다.
- 웹과 앱은 둘 다 메시지를 계속 수신하고 처리할 수 있어야 합니다.
- 즉, 어느 시점에서든 메시지를 처리해야 하므로
Bridge인스턴스는 항상 살아 있어야 하며, 중앙집중형 핸들러가 필요합니다. 그래서 싱글톤(Singleton) 패턴을 사용하여, 앱과 웹 양쪽 모두Bridge인스턴스를 단일하게 유지합니다.
아래는 조금 길지만 Bridge 구현체 코드입니다.
구현체: App 브릿지 예시
// libs/webview-bridge.ts
import { RefObject } from 'react';
import WebView from 'react-native-webview';
type Handler = (payload?: any) => Promise<any> | any;
class WebviewBridge {
private webviewRef: null | RefObject<WebView> = null;
private pendingMap = new Map<string, (data: any) => void>();
private handlers = new Map<string, Handler>();
/** WebView ref를 설정합니다. sendMessage 시 필요 */
setRef = (ref: RefObject<WebView>) => {
this.webviewRef = ref;
};
/** 외부에서 type → handler를 등록합니다 */
registerHandler = (handlers: Record<string, Handler>) => {
Object.entries(handlers).forEach(([type, handler]) => {
this.handlers.set(type, handler);
});
};
/** Web으로 메시지를 보냅니다. 응답은 await로 받을 수 있습니다. */
sendMessage = <T = any>(type: string, payload?: any, timeout = 3000): Promise<T> => {
const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
return new Promise<T>((resolve) => {
this.pendingMap.set(id, resolve);
this.webviewRef?.current?.postMessage(JSON.stringify({ type, payload, id }));
setTimeout(() => {
if (this.pendingMap.has(id)) {
this.pendingMap.delete(id);
resolve(undefined as T);
}
}, timeout);
});
};
/**
* Web에서 들어온 메시지를 처리합니다.
* - replyTo가 있으면 sendMessage에 대한 응답
* - type과 id가 있으면 handler 실행
*/
handleMessage = (event: any) => {
try {
const message = JSON.parse(event.nativeEvent.data);
if (message.replyTo) {
const resolver = this.pendingMap.get(message.replyTo);
resolver?.(message.payload);
this.pendingMap.delete(message.replyTo);
} else if (message.type && message.id) {
const handler = this.handlers.get(message.type);
Promise.resolve(handler?.(message.payload)).then((result) => {
this.webviewRef?.current?.postMessage(JSON.stringify({ replyTo: message.id, payload: result }));
});
}
} catch (e) {
console.warn('WebviewBridge handleMessage parse error:', e);
}
};
}
export const Bridge = new WebviewBridge();
구현체: Web 브릿지 예시
type Handler = (payload?: any) => Promise<any> | any;
export class WebviewBridge {
private pendingMap = new Map<string, (data: any) => void>();
private handlers = new Map<string, Handler>();
constructor() {
window.addEventListener('message', this.handleMessage);
}
/** 앱 → 웹 메시지를 처리할 핸들러 등록 */
registerHandlers(handlers: Record<string, Handler>) {
Object.entries(handlers).forEach(([type, fn]) => {
this.handlers.set(type, fn);
});
}
/** 앱으로 메시지를 보냅니다. 응답은 Promise로 처리됩니다. */
sendMessage = <T = any>(type: string, payload?: any, timeout = 3000): Promise<T> => {
const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
return new Promise<T>((resolve) => {
this.pendingMap.set(id, resolve);
window.ReactNativeWebView?.postMessage(JSON.stringify({ type, payload, id }));
setTimeout(() => {
if (this.pendingMap.has(id)) {
this.pendingMap.delete(id);
resolve(undefined as T);
}
}, timeout);
});
};
/**
* 앱에서 보낸 메시지를 수신하여 처리합니다.
* - replyTo 응답이면 pendingMap에 resolve
* - 요청이면 등록된 handler 실행 후 reply 전송
*/
private handleMessage = async (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data);
if (msg.replyTo && this.pendingMap.has(msg.replyTo)) {
this.pendingMap.get(msg.replyTo)?.(msg.payload);
this.pendingMap.delete(msg.replyTo);
} else if (msg.type && msg.id && this.handlers.has(msg.type)) {
const result = await this.handlers.get(msg.type)?.(msg.payload);
window.ReactNativeWebView?.postMessage(JSON.stringify({ replyTo: msg.id, payload: result }));
}
} catch (e) {
console.warn('[WebBridge] Error parsing message', e);
}
};
}
export const Bridge = new WebviewBridge();
개선된 통신 코드 예시
// App 에서 사용되는 코드
const AppComponent = (props, ref) => {
const webviewRef = useRef<WebView>();
useEffect(() => {
Bridge.registerHandler({
RN_getAppVersion: () => '1.0.0',
});
}, []);
return (
<WebView
ref={webviewRef}
source={{ uri: props.uri }}
onLoadEnd={props.onWebReady}
onMessage={Bridge.handleMessage}
webviewDebuggingEnabled
/>
);
};
// Web 에서 사용되는 코드
const getAppVersion = async () => {
const version = await Bridge.sendMessage('RN_getAppVersion');
console.log('앱 버전:', version);
};
위 예시는 웹 → 앱으로 요청 후 응답을 받는 구조입니다.
그러나 반대도 가능합니다. 웹에서 registerHandlers로 핸들러를 등록해 두었다면,
앱에서도 sendMessage()를 사용하여 동일한 방식으로 async/await로 웹에게 요청을 보내고 응답을 받을 수 있습니다.
마무리
웹과 앱 사이의 통신은 생각보다 까다롭습니다. 단순히 한 쪽에서 메시지를 보내고, 다른 쪽에서 받기만 하는 구조로는 요청과 응답을 정확히 이어붙이기 어렵고, 여러 메시지가 동시에 오가는 상황에서는 더욱 복잡해지기 쉽습니다.
우리가 만든 Bridge 구조는 이러한 문제를 해결하기 위해 만들어졌습니다.
- 요청마다 고유한 식별자(id)를 부여하고,
- 해당 요청에 대한 응답을 정확히 찾아서 처리할 수 있게 해주며,
- 메시지를 보낸 쪽에서는 await를 사용해 마치 함수 하나를 부르는 것처럼 쉽게 결과를 받을 수 있습니다.
이 구조 덕분에 웹과 앱은 정해진 규칙에 따라 서로 요청하고 응답할 수 있고, 어떤 기능이 어디서 호출되든 일관된 흐름으로 로직을 구성할 수 있게 되었습니다.
부록 1. ReactNativeWebview는 어떻게 window 안에 있을까?
window.ReactNativeWebView.postMessage(...)
이 객체는 React Native의 WebView가 주입해주는 전역 객체입니다.
웹 페이지가 WebView 안에서 실행될 때, React Native는 자동으로 window.ReactNativeWebView라는 객체를 만들어두고, 그 안에 메시지를 보낼 수 있는 postMessage() 함수를 넣어줍니다.
즉, 이 객체는 WebView 내부에서만 존재합니다. 로컬 브라우저에서 테스트할 경우 window.ReactNativeWebView는 undefined입니다.
부록 2. message event ?
HTML5에서는 서로 다른 브라우저 창 간의 메시지 통신을 위해 postMessage()와 message 이벤트라는 API가 도입되었습니다.
// 부모 → iframe
iframe.contentWindow.postMessage({ type: 'ping' }, 'https://another.com');
iframe에서는 이렇게 받을 수 있습니다:
window.addEventListener('message', (event) => {
console.log('📨 받은 메시지:', event.data);
});
예제 코드에서 보듯 크로스-오리진 통신(즉, 서로 다른 도메인 간의 통신 )이 가능합니다. 다만, React Native WebView 환경에서는 window.postMessage()는 의미가 없습니다. 반드시 window.ReactNativeWebView.postMessage()를 사용해야 앱으로 메시지가 전달됩니다.